//
//  STPPaymentCardTextField.swift
//  Stripe
//
//  Created by Jack Flintermann on 7/16/15.
//  Copyright (c) 2015 Stripe, Inc. All rights reserved.
//

import UIKit
@_spi(STP) import StripeCore

/// STPPaymentCardTextField is a text field with similar properties to UITextField,
/// but specialized for credit/debit card information. It manages
/// multiple UITextFields under the hood to collect this information. It's
/// designed to fit on a single line, and from a design perspective can be used
/// anywhere a UITextField would be appropriate.
@IBDesignable
open class STPPaymentCardTextField: UIControl, UIKeyInput, STPFormTextFieldDelegate {
    /// :nodoc:
    @objc
    open func textField(
        _ textField: UITextField, shouldChangeCharactersIn range: NSRange,
        replacementString string: String
    ) -> Bool {
        if let textField = textField as? STPFormTextField,
            let delegateProxy = textField.delegateProxy
        {
            return delegateProxy.textField(
                textField, shouldChangeCharactersIn: range, replacementString: string)
        }
        return true
    }

    private var metadataLoadingIndicator: STPCardLoadingIndicator?

    /// - seealso: STPPaymentCardTextFieldDelegate
    @IBOutlet open weak var delegate: STPPaymentCardTextFieldDelegate?
    /// The font used in each child field. Default is `UIFont.systemFont(ofSize:18)`.
    @objc open var font: UIFont = UIFont.systemFont(ofSize: 18) {
        didSet {
            for field in allFields {
                field.font = font
            }

            sizingField.font = font
            clearSizingCache()

            setNeedsLayout()
        }
    }

    /// The text color to be used when entering valid text. Default is `.label`.
    @objc open var textColor: UIColor = {
        if #available(iOS 13.0, *) {
            return .label
        }
        return .black
    }()
    {
        didSet {
            for field in allFields {
                field.defaultColor = textColor
            }
        }
    }

    /// The text color to be used when the user has entered invalid information,
    /// such as an invalid card number.
    /// Default is `.red`.
    @objc open var textErrorColor: UIColor = {
        if #available(iOS 13.0, *) {
            return .systemRed
        }
        return .red
    }()
    {
        didSet {
            for field in allFields {
                field.errorColor = textErrorColor
            }
        }
    }

    /// The text placeholder color used in each child field.
    /// This will also set the color of the card placeholder icon.
    /// Default is `.systemGray2`.
    @objc open var placeholderColor: UIColor = placeholderGrayColor {
        didSet {
            brandImageView.tintColor = placeholderColor

            for field in allFields {
                field.placeholderColor = placeholderColor
            }
        }
    }

    @IBInspectable private var _numberPlaceholder: String?
    /// The placeholder for the card number field.
    /// Default is "4242424242424242".
    /// If this is set to something that resembles a card number, it will automatically
    /// format it as such (in other words, you don't need to add spaces to this string).
    @IBInspectable open var numberPlaceholder: String? {
        get {
            _numberPlaceholder
        }
        set(numberPlaceholder) {
            _numberPlaceholder = numberPlaceholder
            numberField.placeholder = _numberPlaceholder
        }
    }

    @IBInspectable private var _expirationPlaceholder: String?
    /// The placeholder for the expiration field. Defaults to "MM/YY".
    @IBInspectable open var expirationPlaceholder: String? {
        get {
            _expirationPlaceholder
        }
        set(expirationPlaceholder) {
            _expirationPlaceholder = expirationPlaceholder
            expirationField.placeholder = _expirationPlaceholder
        }
    }

    @IBInspectable private var _cvcPlaceholder: String?
    /// The placeholder for the cvc field. Defaults to "CVC".
    @IBInspectable open var cvcPlaceholder: String? {
        get {
            _cvcPlaceholder
        }
        set(cvcPlaceholder) {
            _cvcPlaceholder = cvcPlaceholder
            cvcField.placeholder = _cvcPlaceholder
        }
    }

    @IBInspectable private var _postalCodePlaceholder: String?
    /// The placeholder for the postal code field. Defaults to "ZIP" for United States
    /// or @"Postal" for all other country codes.
    @IBInspectable open var postalCodePlaceholder: String? {
        get {
            _postalCodePlaceholder
        }
        set(postalCodePlaceholder) {
            _postalCodePlaceholder = postalCodePlaceholder
            updatePostalFieldPlaceholder()
        }
    }
    /// The cursor color for the field.
    /// This is a proxy for the view's tintColor property, exposed for clarity only
    /// (in other words, calling setCursorColor is identical to calling setTintColor).
    @objc open var cursorColor: UIColor {
        get {
            tintColor
        }
        set {
            self.tintColor = newValue
        }
    }

    var _borderColor: UIColor? = placeholderGrayColor
    /// The border color for the field.
    /// Can be nil (in which case no border will be drawn).
    /// Default is .systemGray2.
    @objc open var borderColor: UIColor? {
        get {
            _borderColor
        }
        set {
            _borderColor = newValue
            if let borderColor = newValue {
                self.layer.borderColor = (borderColor.copy() as! UIColor).cgColor
            } else {
                self.layer.borderColor = UIColor.clear.cgColor
            }
        }
    }

    var _borderWidth: CGFloat = 1.0
    /// The width of the field's border.
    /// Default is 1.0.
    @objc open var borderWidth: CGFloat {
        get {
            _borderWidth
        }
        set {
            _borderWidth = newValue
            layer.borderWidth = borderWidth
        }
    }

    var _cornerRadius: CGFloat = 5.0
    /// The corner radius for the field's border.
    /// Default is 5.0.
    @objc open var cornerRadius: CGFloat {
        get {
            _cornerRadius
        }
        set {
            _cornerRadius = cornerRadius
            layer.cornerRadius = newValue
        }
    }

    /// The keyboard appearance for the field.
    /// Default is UIKeyboardAppearanceDefault.
    @objc open var keyboardAppearance: UIKeyboardAppearance = .default {
        didSet {
            for field in allFields {
                field.keyboardAppearance = keyboardAppearance
            }
        }
    }

    private var _inputView: UIView?
    /// This behaves identically to setting the inputView for each child text field.
    @objc open override var inputView: UIView? {
        get {
            _inputView
        }
        set(inputView) {
            _inputView = inputView

            for field in allFields {
                field.inputView = inputView
            }
        }
    }

    private var _inputAccessoryView: UIView?
    /// This behaves identically to setting the inputAccessoryView for each child text field.
    @objc open override var inputAccessoryView: UIView? {
        get {
            _inputAccessoryView
        }
        set(inputAccessoryView) {
            _inputAccessoryView = inputAccessoryView

            for field in allFields {
                field.inputAccessoryView = inputAccessoryView
            }
        }
    }
    /// The curent brand image displayed in the receiver.
    @objc open private(set) var brandImage: UIImage?
    /// Whether or not the form currently contains a valid card number,
    /// expiration date, CVC, and postal code (if required).
    /// - seealso: STPCardValidator

    @objc dynamic open var isValid: Bool {
        return viewModel.isValid
    }
    /// Enable/disable selecting or editing the field. Useful when submitting card details to Stripe.

    @objc open override var isEnabled: Bool {
        get {
            super.isEnabled
        }
        set(enabled) {
            super.isEnabled = enabled
            for textField in allFields {
                textField.isEnabled = enabled
            }
        }
    }
    /// The current card number displayed by the field.
    /// May or may not be valid, unless `isValid` is true, in which case it is guaranteed
    /// to be valid.
    @objc open var cardNumber: String? {
        return viewModel.cardNumber
    }
    /// The current expiration month displayed by the field (1 = January, etc).
    /// May or may not be valid, unless `isValid` is true, in which case it is
    /// guaranteed to be valid.
    @objc open var expirationMonth: Int {
        if let monthString = viewModel.expirationMonth, let month = Int(monthString) {
            return month
        }
        return 0
    }
    /// The current expiration month displayed by the field, as a string. T
    /// This may or may not be a valid entry (i.e. "0") unless `isValid` is true.
    /// It may be also 0-prefixed (i.e. "01" for January).
    @objc open var formattedExpirationMonth: String? {
        return viewModel.expirationMonth
    }
    /// The current expiration year displayed by the field, modulo 100
    /// (e.g. the year 2015 will be represented as 15).
    /// May or may not be valid, unless `isValid` is true, in which case it is
    /// guaranteed to be valid.

    @objc open var expirationYear: Int {
        if let yearString = viewModel.expirationYear, let year = Int(yearString) {
            return year
        }
        return 0
    }
    /// The current expiration year displayed by the field, as a string.
    /// This is a 2-digit year (i.e. "15"), and may or may not be a valid entry
    /// unless `isValid` is true.

    @objc open var formattedExpirationYear: String? {
        return viewModel.expirationYear
    }
    /// The current card CVC displayed by the field.
    /// May or may not be valid, unless `isValid` is true, in which case it
    /// is guaranteed to be valid.

    @objc open var cvc: String? {
        return viewModel.cvc
    }

    /// The current card ZIP or postal code displayed by the field.
    @objc open var postalCode: String? {
        get {
            if postalCodeEntryEnabled {
                return viewModel.postalCode
            } else {
                return nil
            }
        }
        set {
            if postalCodeEntryEnabled {
                if newValue != postalCode {
                    setText(newValue, inField: .postalCode)
                }
            }
        }
    }
    /// Controls if a postal code entry field can be displayed to the user.
    /// Default is YES.
    /// If YES, the type of code entry shown is controlled by the set `countryCode`
    /// value. Some country codes may result in no postal code entry being shown if
    /// those countries do not commonly use postal codes.
    /// If NO, no postal code entry will ever be displayed.
    @objc open var postalCodeEntryEnabled: Bool {
        get {
            return viewModel.postalCodeRequired
        }
        set(postalCodeEntryEnabled) {
            viewModel.postalCodeRequested = postalCodeEntryEnabled
        }
    }
    /// The two-letter ISO country code that corresponds to the user's billing address.
    /// If `postalCodeEntryEnabled` is YES, this controls which type of entry is allowed.
    /// If `postalCodeEntryEnabled` is NO, this property currently has no effect.
    /// If set to nil and postal code entry is enabled, the country from the user's current
    /// locale will be filled in. Otherwise the specific country code set will be used.
    /// By default this will fetch the user's current country code from NSLocale.

    @objc open var countryCode: String? {
        get {
            return viewModel.postalCodeCountryCode
        }
        set(cCode) {
            if viewModel.postalCodeCountryCode == cCode {
                return
            }
            let countryCode = (cCode ?? Locale.autoupdatingCurrent.regionCode)
            viewModel.postalCodeCountryCode = countryCode
            updatePostalFieldPlaceholder()

            // This will revalidate and reformat
            setText(postalCode, inField: .postalCode)
        }
    }
    /// Convenience property for creating an `STPPaymentMethodCardParams` from the currently entered information
    /// or programmatically setting the field's contents. For example, if you're using another library
    /// to scan your user's credit card with a camera, you can assemble that data into an `STPPaymentMethodCardParams`
    /// object and set this property to that object to prefill the fields you've collected.
    /// Accessing this property returns a *copied* `cardParams`. The only way to change properties in this
    /// object is to make changes to a `STPPaymentMethodCardParams` you own (retrieved from this text field if desired),
    /// and then set this property to the new value.

    @objc open var cardParams: STPPaymentMethodCardParams {
        get {
            let newParams = internalCardParams.copy() as! STPPaymentMethodCardParams
            newParams.number = cardNumber
            if let monthString = viewModel.expirationMonth, let month = Int(monthString) {
                newParams.expMonth = NSNumber(value: month)
            }
            if let yearString = viewModel.expirationYear, let year = Int(yearString) {
                newParams.expYear = NSNumber(value: year)
            }
            newParams.cvc = cvc
            internalCardParams = newParams
            return internalCardParams
        }
        set(callersCardParams) {
            if callersCardParams.isEqual(self.cardParams) {
                // These are identical card params: Don't take any action.
                return
            }
            /*
                 Due to the way this class is written, programmatically setting field text
                 behaves identically to user entering text (and will have the same forwarding
                 on to next responder logic).

                 We have some custom logic here in the main accesible programmatic setter
                 to dance around this a bit. First we save what is the current responder
                 at the time this method was called. Later logic after text setting should be:
                 1. If we were not first responder, we should still not be first responder
                    (but layout might need updating depending on PAN validity)
                 2. If original field is still not valid, it is still first responder
                    (manually reset it back to first responder)
                 3. Otherwise the first subfield with invalid text should now be first responder
                 */
            let originalSubResponder = currentFirstResponderField()

            /*
                 #1031 small footgun hiding here. Use copies to protect from mutations of
                 `internalCardParams` in the `cardParams` property accessor and any mutations
                 the app code might make to their `callersCardParams` object.
                 */
            let desiredCardParams = callersCardParams.copy() as! STPPaymentMethodCardParams
            internalCardParams = desiredCardParams.copy() as! STPPaymentMethodCardParams

            setText(desiredCardParams.number, inField: .number)
            let expirationPresent =
                desiredCardParams.expMonth != nil && desiredCardParams.expYear != nil
            if expirationPresent {
                let text = String(
                    format: "%02lu%02lu", UInt(desiredCardParams.expMonth?.intValue ?? 0),
                    UInt(desiredCardParams.expYear?.intValue ?? 0) % 100)
                setText(text, inField: .expiration)
            } else {
                setText("", inField: .expiration)
            }
            setText(desiredCardParams.cvc, inField: .CVC)

            if isFirstResponder {
                var fieldType = STPCardFieldType.number
                if let originalSubResponderTag = originalSubResponder?.tag,
                    let lastFieldType = STPCardFieldType(rawValue: originalSubResponderTag)
                {
                    fieldType = lastFieldType
                }
                var state: STPCardValidationState = .incomplete

                switch fieldType {
                case .number:
                    state =
                        viewModel.hasCompleteMetadataForCardNumber
                        ? STPCardValidator.validationState(
                            forNumber: viewModel.cardNumber ?? "", validatingCardBrand: true)
                        : .incomplete
                case .expiration:
                    state = viewModel.validationStateForExpiration()
                case .CVC:
                    state = viewModel.validationStateForCVC()
                case .postalCode:
                    state = viewModel.validationStateForPostalCode()
                }

                if state == .valid {
                    let nextField = _firstInvalidAutoAdvanceField()
                    if let nextField = nextField {
                        nextField.becomeFirstResponder()
                    } else {
                        resignFirstResponder()
                    }
                } else {
                    originalSubResponder?.becomeFirstResponder()
                }
            } else {
                layoutViews(
                    toFocus: nil,
                    becomeFirstResponder: true,
                    animated: false, completion: nil)
            }

            // update the card image, falling back to the number field image if not editing
            if expirationField.isFirstResponder {
                updateImage(for: .expiration)
            } else if cvcField.isFirstResponder {
                updateImage(for: .CVC)
            } else {
                updateImage(for: .number)
            }
            updateCVCPlaceholder()
        }
    }

    /// Causes the text field to begin editing. Presents the keyboard.
    /// - Returns: Whether or not the text field successfully began editing.
    /// - seealso: UIResponder
    @objc @discardableResult open override func becomeFirstResponder() -> Bool {
        let firstResponder = currentFirstResponderField() ?? nextFirstResponderField()
        return firstResponder.becomeFirstResponder()
    }

    /// Causes the text field to stop editing. Dismisses the keyboard.
    /// - Returns: Whether or not the field successfully stopped editing.
    /// - seealso: UIResponder
    @discardableResult open override func resignFirstResponder() -> Bool {
        super.resignFirstResponder()
        let success = currentFirstResponderField()?.resignFirstResponder() ?? false
        layoutViews(
            toFocus: nil,
            becomeFirstResponder: false,
            animated: true, completion: nil)
        updateImage(for: .number)
        return success
    }

    /// Resets all of the contents of all of the fields. If the field is currently being edited, the number field will become selected.
    @objc open func clear() {
        for field in allFields {
            field.text = ""
        }
        viewModel = STPPaymentCardTextFieldViewModel()
        onChange()
        updateImage(for: .number)
        updateCVCPlaceholder()
        weak var weakSelf = self
        layoutViews(
            toFocus: NSNumber(value: STPCardFieldType.postalCode.rawValue),
            becomeFirstResponder: true,
            animated: true
        ) { _ in
            guard let strongSelf = weakSelf else {
                return
            }
            if strongSelf.isFirstResponder {
                strongSelf.numberField.becomeFirstResponder()
            }
        }
    }

    /// Returns the cvc image used for a card brand.
    /// Override this method in a subclass if you would like to provide custom images.
    /// - Parameter cardBrand: The brand of card entered.
    /// - Returns: The cvc image used for a card brand.
    @objc(cvcImageForCardBrand:) open class func cvcImage(for cardBrand: STPCardBrand) -> UIImage?
    {
        return STPImageLibrary.cvcImage(for: cardBrand)
    }

    /// Returns the brand image used for a card brand.
    /// Override this method in a subclass if you would like to provide custom images.
    /// - Parameter cardBrand: The brand of card entered.
    /// - Returns: The brand image used for a card brand.
    @objc(brandImageForCardBrand:) open class func brandImage(for cardBrand: STPCardBrand)
        -> UIImage?
    {
        return STPImageLibrary.cardBrandImage(for: cardBrand)
    }

    /// Returns the error image used for a card brand.
    /// Override this method in a subclass if you would like to provide custom images.
    /// - Parameter cardBrand: The brand of card entered.
    /// - Returns: The error image used for a card brand.
    @objc(errorImageForCardBrand:) open class func errorImage(for cardBrand: STPCardBrand)
        -> UIImage?
    {
        return STPImageLibrary.errorImage(for: cardBrand)
    }

    /// Returns the rectangle in which the receiver draws its brand image.
    /// - Parameter bounds: The bounding rectangle of the receiver.
    /// - Returns: the rectangle in which the receiver draws its brand image.
    @objc(brandImageRectForBounds:) open func brandImageRect(forBounds bounds: CGRect) -> CGRect {
        let height = CGFloat(min(bounds.size.height, brandImageView.image?.size.height ?? 0))
        // the -1 to y here helps the image actually be centered
        return CGRect(
            x: STPPaymentCardTextFieldDefaultPadding,
            y: 0.5 * bounds.size.height - 0.5 * height - 1,
            width: brandImageView.image?.size.width ?? 0.0, height: height)
    }

    /// Returns the rectangle in which the receiver draws the text fields.
    /// - Parameter bounds: The bounding rectangle of the receiver.
    /// - Returns: The rectangle in which the receiver draws the text fields.
    @objc(fieldsRectForBounds:) open func fieldsRect(forBounds bounds: CGRect) -> CGRect {
        let brandImageRect = self.brandImageRect(forBounds: bounds)
        return CGRect(
            x: brandImageRect.maxX, y: 0, width: bounds.width - brandImageRect.maxX,
            height: bounds.height
        )
    }

    @objc internal lazy var brandImageView: UIImageView = UIImageView(
        image: Self.brandImage(for: .unknown))
    @objc internal lazy var fieldsView: UIView = UIView()
    @objc internal lazy var numberField = STPFormTextField()
    @objc internal lazy var expirationField = STPFormTextField()
    @objc internal lazy var cvcField = STPFormTextField()
    @objc internal lazy var postalCodeField = STPFormTextField()

    @objc private lazy var viewModel: STPPaymentCardTextFieldViewModel =
        STPPaymentCardTextFieldViewModel()

    @objc internal var internalCardParams = STPPaymentMethodCardParams()
    @objc internal var allFields: [STPFormTextField] = []
    private lazy var sizingField = STPFormTextField()
    private lazy var sizingLabel = UILabel()
    /*
     These track the input parameters to the brand image setter so that we can
     later perform proper transition animations when new values are set
     */
    private var currentBrandImageFieldType: STPCardFieldType = .number
    private var currentBrandImageBrand: STPCardBrand = .unknown
    /// This is a number-wrapped STPCardFieldType (or nil) that layout uses
    /// to determine how it should move/animate its subviews so that the chosen
    /// text field is fully visible.
    @objc internal var focusedTextFieldForLayout: NSNumber?
    /*
     Creating and measuring the size of attributed strings is expensive so
     cache the values here.
     */
    private var textToWidthCache: [String: NSNumber] = [:]
    private var numberToWidthCache: [String: NSNumber] = [:]
    /// These bits lets us track beginEditing and endEditing for payment text field
    /// as a whole (instead of on a per-subview basis).
    /// DO NOT read this values directly. Use the return value from
    /// `getAndUpdateSubviewEditingTransitionStateFromCall:` which updates them all
    /// and returns you the correct current state for the method you are in.
    /// The state transitons in the should/did begin/end editing callbacks for all
    /// our subfields. If we get a shouldEnd AND a shouldBegin before getting either's
    /// matching didEnd/didBegin, then we are transitioning focus between our subviews
    /// (and so we ourselves should not consider us to have begun or ended editing).
    /// But if we get a should and did called on their own without a matching opposite
    /// pair (shouldBegin/didBegin or shouldEnd/didEnd) then we are transitioning
    /// into/out of our subviews from/to outside of ourselves
    private var isMidSubviewEditingTransitionInternal = false
    private var receivedUnmatchedShouldBeginEditing = false
    private var receivedUnmatchedShouldEndEditing = false

    let STPPaymentCardTextFieldDefaultPadding: CGFloat = 13

    let STPPaymentCardTextFieldDefaultInsets: CGFloat = 13

    let STPPaymentCardTextFieldMinimumPadding: CGFloat = 10

    // MARK: initializers
    /// :nodoc:
    required public init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }

    /// :nodoc:
    public override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }

    func commonInit() {
        STPAnalyticsClient.sharedClient.addClass(
            toProductUsageIfNecessary: STPPaymentCardTextField.self)

        // We're using ivars here because UIAppearance tracks when setters are
        // called, and won't override properties that have already been customized
        layer.borderColor = _borderColor?.cgColor
        layer.cornerRadius = _cornerRadius
        layer.borderWidth = _borderWidth

        clipsToBounds = true

        sizingField = build()
        sizingField.formDelegate = nil
        sizingLabel = UILabel()

        brandImageView.contentMode = .center
        brandImageView.backgroundColor = UIColor.clear
        brandImageView.tintColor = placeholderColor

        let numberField = build()
        // This does not offer quick-type suggestions (as iOS 11.2), but does pick
        // the best keyboard (maybe other, hidden behavior?)
        numberField.textContentType = .creditCardNumber
        numberField.autoFormattingBehavior = .cardNumbers
        numberField.tag = STPCardFieldType.number.rawValue
        numberField.accessibilityLabel = STPLocalizedString(
            "card number", "accessibility label for text field")
        self.numberField = numberField
        numberPlaceholder = viewModel.defaultPlaceholder()

        let expirationField = build()
        expirationField.autoFormattingBehavior = .expiration
        expirationField.tag = STPCardFieldType.expiration.rawValue
        expirationField.alpha = 0
        expirationField.isAccessibilityElement = false
        expirationField.accessibilityLabel = STPLocalizedString(
            "expiration date", "accessibility label for text field")
        self.expirationField = expirationField
        expirationPlaceholder = STPLocalizedString(
            "MM/YY", "label for text field to enter card expiry")

        let cvcField = build()
        cvcField.tag = STPCardFieldType.CVC.rawValue
        cvcField.alpha = 0
        cvcField.isAccessibilityElement = false
        self.cvcField = cvcField
        cvcPlaceholder = nil
        self.cvcField.accessibilityLabel = defaultCVCPlaceholder()

        let postalCodeField = build()
        postalCodeField.textContentType = .postalCode
        postalCodeField.tag = STPCardFieldType.postalCode.rawValue
        postalCodeField.alpha = 0
        postalCodeField.isAccessibilityElement = false
        postalCodeField.keyboardType = .numbersAndPunctuation
        self.postalCodeField = postalCodeField
        // Placeholder is set by country code setter

        fieldsView.clipsToBounds = true
        fieldsView.backgroundColor = UIColor.clear

        allFields = [numberField, expirationField, cvcField, postalCodeField].compactMap { $0 }

        addSubview(self.fieldsView)
        for field in allFields {
            self.fieldsView.addSubview(field)
        }

        addSubview(brandImageView)
        // On small screens, the number field fits ~4 numbers, and the brandImage is just as large.
        // Previously, taps on the brand image would *dismiss* the keyboard. Make it move to the numberField instead
        brandImageView.isUserInteractionEnabled = true
        brandImageView.addGestureRecognizer(
            UITapGestureRecognizer(
                target: numberField,
                action: #selector(UIResponder.becomeFirstResponder)))

        focusedTextFieldForLayout = nil
        updateCVCPlaceholder()
        resetSubviewEditingTransitionState()

        viewModel.postalCodeRequested = true
        countryCode = Locale.autoupdatingCurrent.regionCode
    }

    // MARK: appearance properties
    func clearSizingCache() {
        textToWidthCache = [:]
        numberToWidthCache = [:]
    }

    static let placeholderGrayColor: UIColor = {
        if #available(iOS 13.0, *) {
            return .systemGray2
        }
        return .lightGray
    }()

    /// :nodoc:
    @objc open override var backgroundColor: UIColor? {
        get {
            var defaultColor = UIColor.white
            if #available(iOS 13.0, *) {
                defaultColor = UIColor.systemBackground
            }

            return super.backgroundColor ?? defaultColor
        }
        set {
            super.backgroundColor = newValue
            self.numberField.backgroundColor = newValue
        }
    }

    /// :nodoc:
    @objc open override var contentVerticalAlignment: UIControl.ContentVerticalAlignment {
        get {
            return super.contentVerticalAlignment
        }
        set(contentVerticalAlignment) {
            super.contentVerticalAlignment = contentVerticalAlignment
            for field in allFields {
                field.contentVerticalAlignment = contentVerticalAlignment
            }
            switch contentVerticalAlignment {
            case .center:
                brandImageView.contentMode = .center
            case .bottom:
                brandImageView.contentMode = .bottom
            case .fill:
                brandImageView.contentMode = .top
            case .top:
                brandImageView.contentMode = .top
            @unknown default:
                break
            }
        }
    }

    func updatePostalFieldPlaceholder() {
        if postalCodePlaceholder == nil {
            postalCodeField.placeholder = defaultPostalFieldPlaceholder(forCountryCode: countryCode)
        } else {
            postalCodeField.placeholder = postalCodePlaceholder
        }
    }

    func defaultPostalFieldPlaceholder(forCountryCode countryCode: String?) -> String? {
        if countryCode?.uppercased() == "US" {
            return STPLocalizedString("ZIP", "Short string for zip code (United States only)")
        } else {
            return STPLocalizedString(
                "Postal code", "Short string for postal code (text used in non-US countries)")
        }
    }

    // MARK: UIControl

    // MARK: UIResponder & related methods
    /// :nodoc:
    @objc open override var isFirstResponder: Bool {
        return currentFirstResponderField() != nil
    }

    /// :nodoc:
    @objc open override var canBecomeFirstResponder: Bool {
        let firstResponder = currentFirstResponderField() ?? nextFirstResponderField()
        return firstResponder.canBecomeFirstResponder
    }

    /// Returns the next text field to be edited, in priority order:
    /// 1. If we're currently in a text field, returns the next one (ignoring postalCodeField if postalCodeEntryEnabled == NO)
    /// 2. Otherwise, returns the first invalid field (either cycling back from the end or as it gains 1st responder)
    /// 3. As a final fallback, just returns the last field
    func nextFirstResponderField() -> STPFormTextField {
        let currentFirstResponder = currentFirstResponderField()
        if let currentFirstResponder = currentFirstResponder {
            let index = allFields.firstIndex(of: currentFirstResponder) ?? NSNotFound
            if index != NSNotFound {
                let nextField =
                    allFields.stp_boundSafeObject(at: index + 1) as? STPFormTextField
                if nextField != nil && (postalCodeEntryEnabled || nextField != postalCodeField) {
                    return nextField!
                }
            }
        }

        if (numberField.text?.count ?? 0) == 0 {
            return numberField
        }

        return _firstInvalidAutoAdvanceField() ?? lastSubField()
    }

    func _firstInvalidAutoAdvanceField() -> STPFormTextField? {
        if viewModel.validationStateForExpiration() != .valid {
            return expirationField
        } else if viewModel.validationStateForCVC() != .valid {
            return cvcField
        } else if postalCodeEntryEnabled && viewModel.validationStateForPostalCode() != .valid {
            return postalCodeField
        } else {
            return nil
        }
    }

    func lastSubField() -> STPFormTextField {
        return (postalCodeEntryEnabled ? postalCodeField : cvcField)
    }

    @objc func currentFirstResponderField() -> STPFormTextField? {
        for textField in allFields {
            if textField.isFirstResponder {
                return textField
            }
        }
        return nil
    }

    /// :nodoc:
    @objc open override var canResignFirstResponder: Bool {
        return currentFirstResponderField()?.canResignFirstResponder ?? false
    }

    func previousField() -> STPFormTextField? {
        let currentSubResponder = currentFirstResponderField()
        if let currentSubResponder = currentSubResponder {
            let index = allFields.firstIndex(of: currentSubResponder) ?? NSNotFound
            if index != NSNotFound && index > 0 {
                return allFields[index - 1]
            }
        }
        return nil
    }

    // MARK: public convenience methods

    @objc func valid() -> Bool {
        return isValid
    }

    // MARK: readonly variables

    func setText(_ text: String?, inField field: STPCardFieldType) {
        let nonNilText = text ?? ""
        var textField: STPFormTextField?
        switch field {
        case .number:
            textField = numberField
        case .expiration:
            textField = expirationField
        case .CVC:
            textField = cvcField
        case .postalCode:
            textField = postalCodeField
        }
        textField?.text = nonNilText
    }

    func numberFullWidth() -> CGFloat {
        return CGFloat(
            max(
                width(forCardNumber: viewModel.cardNumber),
                width(forCardNumber: viewModel.defaultPlaceholder())))
    }

    func numberCompressedWidth() -> CGFloat {

        var cardNumber = self.cardNumber
        if (cardNumber?.count ?? 0) == 0 {
            cardNumber = viewModel.defaultPlaceholder()
        }

        let currentBrand = STPCardValidator.brand(forNumber: cardNumber ?? "")
        let sortedCardNumberFormat =
            (STPCardValidator.cardNumberFormat(forCardNumber: cardNumber ?? "") as NSArray)
            .sortedArray(
                using: #selector(getter:NSNumber.uintValue)) as! [NSNumber]
        let fragmentLength = STPCardValidator.fragmentLength(for: currentBrand)
        let maxLength: Int = max(Int(fragmentLength), sortedCardNumberFormat.last!.intValue)

        let maxCompressedString = "".padding(toLength: maxLength, withPad: "8", startingAt: 0)
        return width(forText: maxCompressedString)
    }

    func cvcFieldWidth() -> CGFloat {
        if focusedTextFieldForLayout != NSNumber(value: STPCardFieldType.CVC.rawValue)
            && viewModel.validationStateForCVC() == .valid
        {
            // If we're not focused and have valid text, size exactly to what is entered
            return width(forText: viewModel.cvc)
        } else {
            // Otherwise size to fit our placeholder or what is likely to be the
            // largest possible string enterable (whichever is larger)
            let maxCvcLength = Int(STPCardValidator.maxCVCLength(for: viewModel.brand))
            var longestCvc = "888"
            if maxCvcLength == 4 {
                longestCvc = "8888"
            }

            return CGFloat(max(width(forText: cvcField.placeholder), width(forText: longestCvc)))
        }
    }

    func expirationFieldWidth() -> CGFloat {
        if focusedTextFieldForLayout == nil && viewModel.validationStateForExpiration() == .valid {
            // If we're not focused and have valid text, size exactly to what is entered
            return width(forText: viewModel.rawExpiration)
        } else {
            // Otherwise size to fit our placeholder or what is likely to be the
            // largest possible string enterable (whichever is larger)
            return CGFloat(
                max(width(forText: expirationField.placeholder), width(forText: "88/88")))
        }
    }

    func postalCodeFieldFullWidth() -> CGFloat {
        let compressedWidth = postalCodeFieldCompressedWidth()
        let currentTextWidth = width(forText: viewModel.postalCode)

        if currentTextWidth <= compressedWidth {
            return compressedWidth
        } else if countryCode?.uppercased() == "US" {
            // This format matches ZIP+4 which is currently disabled since it is
            // not used for billing, but could be useful for future shipping addr purposes
            return width(forText: "88888-8888 ")
        } else {
            // This format more closely matches the typical max UK/Canadian size which is our most common non-US market currently
            return width(forText: "888 8888 ")
        }
    }

    func postalCodeFieldCompressedWidth() -> CGFloat {
        var maxTextWidth: CGFloat = 0
        if countryCode?.uppercased() == "US" {
            // The QuickType ZIP suggestion adds a space at the end, so we will too for calculating our bounds
            maxTextWidth = width(forText: "88888 ")
        } else {
            // This format more closely matches the typical max UK/Canadian size which is our most common non-US market currently
            maxTextWidth = width(forText: "888 8888 ")
        }

        let placeholderWidth = width(
            forText: defaultPostalFieldPlaceholder(forCountryCode: countryCode))
        return CGFloat(max(maxTextWidth, placeholderWidth))
    }

    /// :nodoc:
    @objc open override var intrinsicContentSize: CGSize {

        let imageSize = brandImageView.image?.size

        sizingField.text = viewModel.defaultPlaceholder()
        sizingField.sizeToFit()
        let textHeight = sizingField.frame.height
        let imageHeight = (imageSize?.height ?? 0.0) + (STPPaymentCardTextFieldDefaultInsets)
        let height = ceil(CGFloat((max(max(imageHeight, textHeight), 44))))

        var width =
            STPPaymentCardTextFieldDefaultInsets + (imageSize?.width ?? 0.0)
            + STPPaymentCardTextFieldDefaultInsets + numberFullWidth()
            + STPPaymentCardTextFieldDefaultInsets

        width = ceil(width)

        return CGSize(width: width, height: height)
    }

    enum STPCardTextFieldState: Int {
        case visible
        case compressed
        case hidden
    }

    func minimumPaddingForViews(
        withWidth width: CGFloat,
        pan panVisibility: STPCardTextFieldState,
        expiry expiryVisibility: STPCardTextFieldState,
        cvc cvcVisibility: STPCardTextFieldState,
        postal postalVisibility: STPCardTextFieldState
    ) -> CGFloat {

        var requiredWidth: CGFloat = 0
        var paddingsRequired: CGFloat = -1

        if panVisibility != .hidden {
            paddingsRequired += 1
            requiredWidth +=
                (panVisibility == .compressed) ? numberCompressedWidth() : numberFullWidth()
        }

        if expiryVisibility != .hidden {
            paddingsRequired += 1
            requiredWidth += expirationFieldWidth()
        }

        if cvcVisibility != .hidden {
            paddingsRequired += 1
            requiredWidth += cvcFieldWidth()
        }

        if postalVisibility != .hidden && postalCodeEntryEnabled {
            paddingsRequired += 1
            requiredWidth +=
                (postalVisibility == .compressed)
                ? postalCodeFieldCompressedWidth() : postalCodeFieldFullWidth()
        }

        if paddingsRequired > 0 {
            return ceil((width - requiredWidth) / paddingsRequired)
        } else {
            return STPPaymentCardTextFieldMinimumPadding
        }
    }

    /// :nodoc:
    @objc
    open override func layoutSubviews() {
        super.layoutSubviews()
        recalculateSubviewLayout()
    }

    func recalculateSubviewLayout() {

        let bounds = self.bounds

        brandImageView.frame = brandImageRect(forBounds: bounds)
        let fieldsViewRect = fieldsRect(forBounds: bounds)
        fieldsView.frame = fieldsViewRect

        let availableFieldsWidth = fieldsViewRect.width - (2 * STPPaymentCardTextFieldDefaultInsets)

        // These values are filled in via the if statements and then used
        // to do the proper layout at the end
        let fieldsHeight = fieldsViewRect.height
        var hPadding = STPPaymentCardTextFieldDefaultPadding
        var panVisibility: STPCardTextFieldState = .visible
        var expiryVisibility: STPCardTextFieldState = .visible
        var cvcVisibility: STPCardTextFieldState = .visible
        var postalVisibility: STPCardTextFieldState = postalCodeEntryEnabled ? .visible : .hidden

        let calculateMinimumPaddingWithLocalVars: (() -> CGFloat) = {
            return self.minimumPaddingForViews(
                withWidth: availableFieldsWidth,
                pan: panVisibility,
                expiry: expiryVisibility,
                cvc: cvcVisibility,
                postal: postalVisibility)
        }

        hPadding = calculateMinimumPaddingWithLocalVars()

        if hPadding >= STPPaymentCardTextFieldMinimumPadding {
            // Can just render everything at full size
            // Do Nothing
        } else {
            // Need to do selective view compression/hiding

            if focusedTextFieldForLayout == nil {
                /*
                             No field is currently being edited -

                             Render all fields visible:
                             Show compressed PAN, visible CVC and expiry, fill remaining space
                             with postal if necessary

                             The most common way to be in this state is the user finished entry
                             and has moved on to another field (so we want to show summary)
                             but possibly some fields are invalid
                             */
                while hPadding < STPPaymentCardTextFieldMinimumPadding {
                    // Try hiding things in this order
                    if panVisibility == .visible {
                        panVisibility = .compressed
                    } else if postalVisibility == .visible {
                        postalVisibility = .compressed
                    } else {
                        // Can't hide anything else, set to minimum and stop
                        hPadding = STPPaymentCardTextFieldMinimumPadding
                        break
                    }
                    hPadding = calculateMinimumPaddingWithLocalVars()
                }
            } else {
                switch STPCardFieldType(rawValue: focusedTextFieldForLayout?.intValue ?? 0)! {
                case .number:
                    /*
                                             The user is entering PAN

                                             It must be fully visible. Everything else is optional
                                             */

                    while hPadding < STPPaymentCardTextFieldMinimumPadding {
                        if postalVisibility == .visible {
                            postalVisibility = .compressed
                        } else if postalVisibility == .compressed {
                            postalVisibility = .hidden
                        } else if cvcVisibility == .visible {
                            cvcVisibility = .hidden
                        } else if expiryVisibility == .visible {
                            expiryVisibility = .hidden
                        } else {
                            hPadding = STPPaymentCardTextFieldMinimumPadding
                            break
                        }
                        hPadding = calculateMinimumPaddingWithLocalVars()
                    }
                case .expiration:
                    /*
                                             The user is entering expiration date

                                             It must be fully visible, and the next and previous fields
                                             must be visible so they can be tapped over to
                                             */
                    while hPadding < STPPaymentCardTextFieldMinimumPadding {
                        if panVisibility == .visible {
                            panVisibility = .compressed
                        } else if postalVisibility == .visible {
                            postalVisibility = .compressed
                        } else if postalVisibility == .compressed {
                            postalVisibility = .hidden
                        } else {
                            hPadding = STPPaymentCardTextFieldMinimumPadding
                            break
                        }
                        hPadding = calculateMinimumPaddingWithLocalVars()
                    }
                case .CVC:
                    /*
                                             The user is entering CVC

                                             It must be fully visible, and the next and previous fields
                                             must be visible so they can be tapped over to (although
                                             there might not be a next field)
                                             */
                    while hPadding < STPPaymentCardTextFieldMinimumPadding {
                        if panVisibility == .visible {
                            panVisibility = .compressed
                        } else if postalVisibility == .visible {
                            postalVisibility = .compressed
                        } else if panVisibility == .compressed {
                            panVisibility = .hidden
                        } else {
                            hPadding = STPPaymentCardTextFieldMinimumPadding
                            break
                        }
                        hPadding = calculateMinimumPaddingWithLocalVars()
                    }
                case .postalCode:
                    /*
                                             The user is entering postal code

                                             It must be fully visible, and the previous field must
                                             be visible
                                             */
                    while hPadding < STPPaymentCardTextFieldMinimumPadding {
                        if panVisibility == .visible {
                            panVisibility = .compressed
                        } else if panVisibility == .compressed {
                            panVisibility = .hidden
                        } else if expiryVisibility == .visible {
                            expiryVisibility = .hidden
                        } else {
                            hPadding = STPPaymentCardTextFieldMinimumPadding
                            break
                        }
                        hPadding = calculateMinimumPaddingWithLocalVars()
                    }
                }
            }
        }

        // -- Do layout here --
        var xOffset = STPPaymentCardTextFieldDefaultInsets
        var width: CGFloat = 0

        // Make all fields actually slightly wider than needed so that when the
        // cursor is at the end position the contents aren't clipped off to the left side
        let additionalWidth = self.width(forText: "8")

        if panVisibility == .compressed {
            // Need to lower xOffset so pan is partially off-screen

            let hasEnteredCardNumber = (cardNumber?.count ?? 0) > 0
            let compressedCardNumber =
                viewModel.compressedCardNumber(withPlaceholder: numberPlaceholder) ?? ""
            let cardNumberToHide = (hasEnteredCardNumber ? cardNumber : numberPlaceholder)?
                .stp_string(
                    byRemovingSuffix: compressedCardNumber)

            if (cardNumberToHide?.count ?? 0) > 0
                && STPCardValidator.stringIsNumeric(cardNumberToHide ?? "")
            {
                width =
                    hasEnteredCardNumber ? self.width(forCardNumber: cardNumber) : numberFullWidth()

                let hiddenWidth = self.width(forCardNumber: cardNumberToHide)
                xOffset -= hiddenWidth
                let maskView = UIView(
                    frame: CGRect(
                        x: hiddenWidth,
                        y: 0,
                        width: width - hiddenWidth,
                        height: fieldsHeight))
                maskView.backgroundColor = UIColor.black
                if #available(iOS 13.0, *) {
                    maskView.backgroundColor = UIColor.label
                }
                maskView.isOpaque = true
                maskView.isUserInteractionEnabled = false
                UIView.performWithoutAnimation({
                    self.numberField.mask = maskView
                })
            } else {
                width = numberCompressedWidth()
                UIView.performWithoutAnimation({
                    self.numberField.mask = nil
                })
            }
        } else {
            width = numberFullWidth()
            UIView.performWithoutAnimation({
                self.numberField.mask = nil
            })

            if panVisibility == .hidden {
                // Need to lower xOffset so pan is fully off screen
                xOffset = xOffset - width - hPadding
            }
        }

        numberField.frame = CGRect(
            x: xOffset, y: 0,
            width: CGFloat(min(width + additionalWidth, fieldsView.frame.width - additionalWidth)),
            height: fieldsHeight)
        xOffset += width + hPadding

        width = expirationFieldWidth()
        expirationField.frame = CGRect(
            x: xOffset, y: 0, width: width + additionalWidth, height: fieldsHeight)
        // If the field isn't visible, we don't want to move the xOffset forward.
        if expiryVisibility != .hidden {
            xOffset += width + hPadding
        }

        width = cvcFieldWidth()
        cvcField.frame = CGRect(
            x: xOffset, y: 0, width: width + additionalWidth, height: fieldsHeight)
        if cvcVisibility != .hidden {
            xOffset += width + hPadding
        }

        if postalCodeEntryEnabled {
            width = fieldsView.frame.size.width - xOffset - STPPaymentCardTextFieldDefaultInsets
            postalCodeField.frame = CGRect(
                x: xOffset, y: 0, width: width + additionalWidth, height: fieldsHeight)
        }

        let updateFieldVisibility: ((STPFormTextField?, STPCardTextFieldState) -> Void)? = {
            field, fieldState in
            if fieldState == .hidden {
                field?.alpha = 0.0
                field?.isAccessibilityElement = false
            } else {
                field?.alpha = 1.0
                field?.isAccessibilityElement = true
            }
        }

        updateFieldVisibility?(numberField, panVisibility)
        updateFieldVisibility?(expirationField, expiryVisibility)
        updateFieldVisibility?(cvcField, cvcVisibility)
        updateFieldVisibility?(postalCodeField, postalCodeEntryEnabled ? postalVisibility : .hidden)
    }

    // MARK: - private helper methods
    func build() -> STPFormTextField {
        let textField = STPFormTextField(frame: CGRect.zero)
        textField.backgroundColor = UIColor.clear
        // setCountryCode: updates the postalCodeField keyboardType, this is safe
        textField.keyboardType = .asciiCapableNumberPad
        textField.textAlignment = .left
        textField.font = font
        textField.defaultColor = textColor
        textField.errorColor = textErrorColor
        textField.placeholderColor = placeholderColor
        textField.formDelegate = self
        textField.validText = true
        return textField
    }

    typealias STPLayoutAnimationCompletionBlock = (Bool) -> Void

    func layoutViews(
        toFocus focusedField: NSNumber?,
        becomeFirstResponder shouldBecomeFirstResponder: Bool,
        animated: Bool,
        completion: STPLayoutAnimationCompletionBlock?
    ) {

        var fieldtoFocus = focusedField

        if fieldtoFocus == nil
            && !(focusedTextFieldForLayout == NSNumber(value: STPCardFieldType.number.rawValue))
        {
            fieldtoFocus = NSNumber(value: STPCardFieldType.number.rawValue)
            if shouldBecomeFirstResponder {
                numberField.becomeFirstResponder()
            }
        }

        if (fieldtoFocus == nil && focusedTextFieldForLayout == nil)
            || (fieldtoFocus != nil && (focusedTextFieldForLayout == fieldtoFocus))
        {
            if let completion = completion {
                completion(true)
            }
            return
        }

        focusedTextFieldForLayout = fieldtoFocus

        let animations: (() -> Void)? = {
            self.recalculateSubviewLayout()
        }

        if animated {
            let duration: TimeInterval = 0.3
            if let animations = animations {
                UIView.animate(
                    withDuration: duration,
                    delay: 0,
                    usingSpringWithDamping: 0.85,
                    initialSpringVelocity: 0,
                    options: [],
                    animations: animations,
                    completion: completion)
            }
        } else {
            animations?()
        }
    }

    func width(forAttributedText attributedText: NSAttributedString?) -> CGFloat {
        // UITextField doesn't seem to size correctly here for unknown reasons
        // But UILabel reliably calculates size correctly using this method
        sizingLabel.attributedText = attributedText
        sizingLabel.sizeToFit()
        return ceil(sizingLabel.bounds.width)

    }

    func width(forText text: String?) -> CGFloat {
        guard let text = text, text.count > 0 else {
            return 0
        }

        if let cachedValue = textToWidthCache[text] {
            return CGFloat(cachedValue.doubleValue)
        }
        sizingField.autoFormattingBehavior = .none
        sizingField.text = STPNonLocalizedString(text)
        let cachedValue = NSNumber(
            value: Float(width(forAttributedText: sizingField.attributedText)))
        textToWidthCache[text] = cachedValue
        return CGFloat(cachedValue.doubleValue)
    }

    func width(forCardNumber cardNumber: String?) -> CGFloat {
        guard let cardNumber = cardNumber, cardNumber.count > 0 else {
            return 0
        }

        if let cachedValue = numberToWidthCache[cardNumber] {
            return CGFloat(cachedValue.doubleValue)
        }
        sizingField.autoFormattingBehavior = .cardNumbers
        sizingField.text = cardNumber
        let cachedValue = NSNumber(
            value: Float(width(forAttributedText: sizingField.attributedText)))
        numberToWidthCache[cardNumber] = cachedValue
        return CGFloat(cachedValue.doubleValue)
    }

    // MARK: STPFormTextFieldDelegate
    @objc(formTextFieldDidBackspaceOnEmpty:) func formTextFieldDidBackspace(
        onEmpty formTextField: STPFormTextField
    ) {
        let previous = previousField()
        previous?.becomeFirstResponder()
        UIAccessibility.post(notification: .screenChanged, argument: nil)
        if previous?.hasText ?? false {
            previous?.deleteBackward()
        }
    }

    @objc(formTextField:modifyIncomingTextChange:) func formTextField(
        _ formTextField: STPFormTextField,
        modifyIncomingTextChange input: NSAttributedString
    ) -> NSAttributedString {
        guard let fieldType = STPCardFieldType(rawValue: formTextField.tag) else {
            return NSAttributedString(string: "")
        }
        switch fieldType {
        case .number:
            viewModel.cardNumber = input.string
            setNeedsLayout()
        case .expiration:
            viewModel.rawExpiration = input.string
        case .CVC:
            viewModel.cvc = input.string
        case .postalCode:
            viewModel.postalCode = input.string
            setNeedsLayout()
        }

        switch fieldType {
        case .number:
            return NSAttributedString(
                string: viewModel.cardNumber ?? "",
                attributes: numberField.defaultTextAttributes)
        case .expiration:
            return NSAttributedString(
                string: viewModel.rawExpiration ?? "",
                attributes: expirationField.defaultTextAttributes)
        case .CVC:
            return NSAttributedString(
                string: viewModel.cvc ?? "",
                attributes: cvcField.defaultTextAttributes)
        case .postalCode:
            return NSAttributedString(
                string: viewModel.postalCode ?? "",
                attributes: cvcField.defaultTextAttributes)
        }
    }

    @objc(formTextFieldTextDidChange:) func formTextFieldTextDidChange(
        _ formTextField: STPFormTextField
    ) {
        guard let fieldType = STPCardFieldType(rawValue: formTextField.tag) else {
            return
        }

        formTextField.validText = true

        switch fieldType {
        case .number:
            let number = viewModel.cardNumber

            // Changing the card number field can invalidate the cvc, e.g. going from 4 digit Amex cvc to 3 digit Visa
            // it is not expected that the brand will change based on network response so we update this immediately
            // as well as in the completion just in case
            updateCVCPlaceholder()
            cvcField.validText = viewModel.validationStateForCVC() != .invalid
            updateImage(for: fieldType)

            if viewModel.hasCompleteMetadataForCardNumber {
                let state = STPCardValidator.validationState(
                    forNumber: viewModel.cardNumber ?? "", validatingCardBrand: true)
                updateCVCPlaceholder()
                cvcField.validText = viewModel.validationStateForCVC() != .invalid
                formTextField.validText = state != .invalid

                if state == .valid {
                    // auto-advance
                    nextFirstResponderField().becomeFirstResponder()
                    UIAccessibility.post(notification: .screenChanged, argument: nil)
                }
            } else {
                viewModel.validationStateForCardNumber(handler: { state in
                    if self.viewModel.cardNumber == number {
                        self.updateCVCPlaceholder()
                        self.cvcField.validText = self.viewModel.validationStateForCVC() != .invalid
                        formTextField.validText = state != .invalid
                        if state == .valid {
                            // log that user entered full complete PAN before we got a network response
                            STPAnalyticsClient.sharedClient
                                .logUserEnteredCompletePANBeforeMetadataLoaded(
                                    with: STPPaymentConfiguration.shared)
                        }
                        self.onChange()
                    }
                    // Update image on response because we may want to remove the loading indicator
                    if let tag = (self.currentFirstResponderField() ?? self.numberField)?.tag,
                        let current = STPCardFieldType(rawValue: tag)
                    {
                        self.updateImage(for: current)
                    }
                    // no auto-advance
                })

                if viewModel.isNumberMaxLength {
                    let isValidLuhn = STPCardValidator.stringIsValidLuhn(viewModel.cardNumber ?? "")
                    formTextField.validText = isValidLuhn
                    if isValidLuhn {
                        // auto-advance
                        nextFirstResponderField().becomeFirstResponder()
                        UIAccessibility.post(notification: .screenChanged, argument: nil)
                    }
                }
            }
        case .expiration:
            let state = viewModel.validationStateForExpiration()
            formTextField.validText = state != .invalid
            if state == .valid {
                // auto-advance
                nextFirstResponderField().becomeFirstResponder()
                UIAccessibility.post(notification: .screenChanged, argument: nil)
            }
        case .CVC:
            let state = viewModel.validationStateForCVC()
            formTextField.validText = state != .invalid
            if state == .valid {
                /*
                                     Even though any CVC longer than the min required CVC length
                                     is valid, we don't want to forward on to the next field
                                     unless it is actually >= the max cvc length (otherwise when
                                     postal code is showing, you can't easily enter CVCs longer than
                                     the minimum.
                                     */
                let sanitizedCvc = STPCardValidator.sanitizedNumericString(
                    for: formTextField.text ?? "")
                if sanitizedCvc.count >= STPCardValidator.maxCVCLength(for: viewModel.brand) {
                    // auto-advance
                    nextFirstResponderField().becomeFirstResponder()
                    UIAccessibility.post(notification: .screenChanged, argument: nil)
                }
            }
        case .postalCode:
            formTextField.validText = viewModel.validationStateForExpiration() != .invalid
        // no auto-advance
        /*
                            Similar to the UX problems on CVC, since our Postal Code validation
                            is pretty light, we want to block auto-advance here. In the US, this
                            allows users to enter 9 digit zips if they want, and as many as they
                            need in non-US countries (where >0 characters is "valid")
                            */
        }

        onChange()
    }

    enum STPFieldEditingTransitionCallSite: Int {
        case shouldBegin
        case shouldEnd
        case didBegin
        case didEnd
    }

    // Explanation of the logic here is with the definition of these properties
    // at the top of this file
    @discardableResult func getAndUpdateSubviewEditingTransitionState(
        fromCall sendingMethod: STPFieldEditingTransitionCallSite
    ) -> Bool {
        var stateToReturn: Bool
        switch sendingMethod {
        case .shouldBegin:
            receivedUnmatchedShouldBeginEditing = true
            if receivedUnmatchedShouldEndEditing {
                isMidSubviewEditingTransitionInternal = true
            }
            stateToReturn = isMidSubviewEditingTransitionInternal
        case .shouldEnd:
            receivedUnmatchedShouldEndEditing = true
            if receivedUnmatchedShouldBeginEditing {
                isMidSubviewEditingTransitionInternal = true
            }
            stateToReturn = isMidSubviewEditingTransitionInternal
        case .didBegin:
            stateToReturn = isMidSubviewEditingTransitionInternal
            receivedUnmatchedShouldBeginEditing = false
            if receivedUnmatchedShouldEndEditing == false {
                isMidSubviewEditingTransitionInternal = false
            }
        case .didEnd:
            stateToReturn = isMidSubviewEditingTransitionInternal
            receivedUnmatchedShouldEndEditing = false
            if receivedUnmatchedShouldBeginEditing == false {
                isMidSubviewEditingTransitionInternal = false
            }
        }

        return stateToReturn
    }

    func resetSubviewEditingTransitionState() {
        isMidSubviewEditingTransitionInternal = false
        receivedUnmatchedShouldBeginEditing = false
        receivedUnmatchedShouldEndEditing = false
    }

    /// :nodoc:
    @objc
    open func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
        getAndUpdateSubviewEditingTransitionState(fromCall: .shouldBegin)
        return true
    }

    /// :nodoc:
    @objc
    open func textFieldDidBeginEditing(_ textField: UITextField) {
        let isMidSubviewEditingTransition = getAndUpdateSubviewEditingTransitionState(
            fromCall: .didBegin)

        layoutViews(
            toFocus: NSNumber(value: textField.tag),
            becomeFirstResponder: true,
            animated: true, completion: nil)

        if !isMidSubviewEditingTransition {
            if delegate?.responds(
                to: #selector(STPAddCardViewController.paymentCardTextFieldDidBeginEditing(_:)))
                ?? false
            {
                delegate?.paymentCardTextFieldDidBeginEditing?(self)
            }
        }

        guard let cardType = STPCardFieldType(rawValue: textField.tag) else {
            return
        }
        switch cardType {
        case .number:
            (textField as? STPFormTextField)?.validText = true
            if delegate?.responds(
                to: #selector(
                    STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidBeginEditingNumber(_:))
            ) ?? false {
                delegate?.paymentCardTextFieldDidBeginEditingNumber?(self)
            }
        case .CVC:
            if delegate?.responds(
                to: #selector(STPAddCardViewController.paymentCardTextFieldDidBeginEditingCVC(_:)))
                ?? false
            {
                delegate?.paymentCardTextFieldDidBeginEditingCVC?(self)
            }
        case .expiration:
            if delegate?.responds(
                to: #selector(
                    STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidBeginEditingExpiration(
                        _:)))
                ?? false
            {
                delegate?.paymentCardTextFieldDidBeginEditingExpiration?(self)
            }
        case .postalCode:
            if delegate?.responds(
                to: #selector(
                    STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidBeginEditingPostalCode(
                        _:)))
                ?? false
            {
                delegate?.paymentCardTextFieldDidBeginEditingPostalCode?(self)
            }
        }
        updateImage(for: cardType)
    }

    /// :nodoc:
    @objc
    open func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
        getAndUpdateSubviewEditingTransitionState(fromCall: .shouldEnd)
        updateImage(for: .number)
        return true
    }

    /// :nodoc:
    @objc
    open func textFieldDidEndEditing(_ textField: UITextField) {
        let isMidSubviewEditingTransition = getAndUpdateSubviewEditingTransitionState(
            fromCall: .didEnd)

        guard let cardType = STPCardFieldType(rawValue: textField.tag) else {
            return
        }

        switch cardType {
        case .number:
            viewModel.validationStateForCardNumber(handler: { state in
                if state == .incomplete && !textField.isEditing {
                    (textField as? STPFormTextField)?.validText = false
                }
            })
            if delegate?.responds(
                to: #selector(
                    STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidEndEditingNumber(_:)))
                ?? false
            {
                delegate?.paymentCardTextFieldDidEndEditingNumber?(self)
            }
        case .CVC:
            if delegate?.responds(
                to: #selector(STPAddCardViewController.paymentCardTextFieldDidEndEditingCVC(_:)))
                ?? false
            {
                delegate?.paymentCardTextFieldDidEndEditingCVC?(self)
            }
        case .expiration:
            if delegate?.responds(
                to: #selector(
                    STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidEndEditingExpiration(_:))
            ) ?? false {
                delegate?.paymentCardTextFieldDidEndEditingExpiration?(self)
            }
        case .postalCode:
            if delegate?.responds(
                to: #selector(
                    STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidEndEditingPostalCode(_:))
            ) ?? false {
                delegate?.paymentCardTextFieldDidEndEditingPostalCode?(self)
            }
        }

        if !isMidSubviewEditingTransition {
            layoutViews(
                toFocus: nil,
                becomeFirstResponder: false,
                animated: true, completion: nil)
            updateImage(for: .number)
            if delegate?.responds(
                to: #selector(STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidEndEditing(_:))
            )
                ?? false
            {
                delegate?.paymentCardTextFieldDidEndEditing?(self)
            }
        }
    }

    /// :nodoc:
    @objc
    open func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        if textField == lastSubField() && _firstInvalidAutoAdvanceField() == nil {
            // User pressed return in the last field, and all fields are valid
            if delegate?.responds(
                to: #selector(
                    STPAddCardViewController.paymentCardTextFieldWillEndEditing(forReturn:)))
                ?? false
            {
                delegate?.paymentCardTextFieldWillEndEditing?(forReturn: self)
            }
            resignFirstResponder()
        } else {
            // otherwise, move to the next field
            nextFirstResponderField().becomeFirstResponder()
            UIAccessibility.post(notification: .screenChanged, argument: nil)
        }

        return false
    }

    @objc internal func brandImage(
        for fieldType: STPCardFieldType, validationState: STPCardValidationState
    ) -> UIImage? {
        switch fieldType {
        case .number:
            if validationState == .invalid {
                return STPPaymentCardTextField.errorImage(for: viewModel.brand)
            } else {
                if viewModel.hasCompleteMetadataForCardNumber {
                    return Self.brandImage(for: viewModel.brand)
                } else {
                    return Self.brandImage(for: .unknown)
                }
            }
        case .CVC:
            return STPPaymentCardTextField.cvcImage(for: viewModel.brand)
        case .expiration:
            return Self.brandImage(for: viewModel.brand)
        case .postalCode:
            return Self.brandImage(for: viewModel.brand)
        }
    }

    func brandImageAnimationOptions(
        forNewType newType: STPCardFieldType,
        newBrand: STPCardBrand,
        oldType: STPCardFieldType,
        oldBrand: STPCardBrand
    ) -> UIView.AnimationOptions {

        if newType == .CVC && oldType != .CVC {
            // Transitioning to show CVC

            if newBrand != .amex {
                // CVC is on the back
                return [.curveEaseInOut, .transitionFlipFromRight]
            }
        } else if newType != .CVC && oldType == .CVC {
            // Transitioning to stop showing CVC

            if oldBrand != .amex {
                // CVC was on the back
                return [.curveEaseInOut, .transitionFlipFromLeft]
            }
        }

        // All other cases just cross dissolve
        return [.curveEaseInOut, .transitionCrossDissolve]

    }

    func updateImage(for fieldType: STPCardFieldType) {

        let addLoadingIndicator: (() -> Void)? = {
            if self.metadataLoadingIndicator == nil {
                self.metadataLoadingIndicator = STPCardLoadingIndicator()

                self.metadataLoadingIndicator?.translatesAutoresizingMaskIntoConstraints = false
                if let metadataLoadingIndicator = self.metadataLoadingIndicator {
                    self.addSubview(metadataLoadingIndicator)
                }
                NSLayoutConstraint.activate(
                    [
                        self.metadataLoadingIndicator?.rightAnchor.constraint(
                            equalTo: self.brandImageView.rightAnchor),
                        self.metadataLoadingIndicator?.topAnchor.constraint(
                            equalTo: self.brandImageView.topAnchor),
                    ].compactMap { $0 })
            }

            let loadingIndicator = self.metadataLoadingIndicator
            if !(loadingIndicator?.isHidden ?? false) {
                return
            }

            loadingIndicator?.alpha = 0.0
            loadingIndicator?.isHidden = false
            UIView.animate(
                withDuration: 0.6, delay: 0, options: .curveEaseInOut,
                animations: {
                    loadingIndicator?.alpha = 1.0
                }
            ) { _ in
                loadingIndicator?.alpha = 1.0
            }
        }

        let removeLoadingIndicator: (() -> Void)? = {
            if self.metadataLoadingIndicator != nil
                && !(self.metadataLoadingIndicator?.isHidden ?? false)
            {
                let loadingIndicator = self.metadataLoadingIndicator

                UIView.animate(
                    withDuration: 0.6, delay: 0, options: .curveEaseInOut,
                    animations: {
                        loadingIndicator?.alpha = 0.0
                    }
                ) { _ in
                    loadingIndicator?.alpha = 0.0
                    loadingIndicator?.isHidden = true
                }
            }
        }

        let applyBrandImage: ((STPCardFieldType, STPCardValidationState) -> Void)? = {
            applyFieldType, validationState in
            let image = self.brandImage(for: applyFieldType, validationState: validationState)
            if !(image == self.brandImageView.image) {

                let newBrand = self.viewModel.brand
                let imageAnimationOptions = self.brandImageAnimationOptions(
                    forNewType: fieldType,
                    newBrand: newBrand,
                    oldType: self.currentBrandImageFieldType,
                    oldBrand: self.currentBrandImageBrand)

                self.currentBrandImageFieldType = applyFieldType
                self.currentBrandImageBrand = newBrand

                UIView.transition(
                    with: self.brandImageView,
                    duration: 0.2,
                    options: imageAnimationOptions,
                    animations: {
                        self.brandImageView.image = image
                    })
            }
        }

        if !(viewModel.hasCompleteMetadataForCardNumber)
            && STPBINRange.isLoadingCardMetadata(forPrefix: viewModel.cardNumber ?? "")
        {
            applyBrandImage?(.number, .incomplete)
            // delay a bit before showing loading indicator because the response may come quickly
            DispatchQueue.main.asyncAfter(
                deadline: DispatchTime.now() + Double(
                    Int64(kCardLoadingAnimationDelay * Double(NSEC_PER_SEC)))
                    / Double(NSEC_PER_SEC),
                execute: {
                    if !(self.viewModel.hasCompleteMetadataForCardNumber)
                        && STPBINRange.isLoadingCardMetadata(
                            forPrefix: self.viewModel.cardNumber ?? "")
                    {
                        addLoadingIndicator?()
                    }
                })
        } else {
            removeLoadingIndicator?()

            switch fieldType {
            case .number:
                applyBrandImage?(
                    .number,
                    STPCardValidator.validationState(
                        forNumber: viewModel.cardNumber ?? "", validatingCardBrand: true))
            case .expiration:
                applyBrandImage?(fieldType, (viewModel.validationStateForExpiration()))
            case .CVC:
                applyBrandImage?(fieldType, (viewModel.validationStateForCVC()))
            case .postalCode:
                applyBrandImage?(fieldType, (viewModel.validationStateForPostalCode()))
            }
        }
    }

    func defaultCVCPlaceholder() -> String? {
        if viewModel.brand == .amex {
            return STPLocalizedString("CVV", "Label for entering CVV in text field")
        } else {
            return STPLocalizedString("CVC", "Label for entering CVC in text field")
        }
    }

    func updateCVCPlaceholder() {
        if let cvcPlaceholder = cvcPlaceholder {
            cvcField.placeholder = cvcPlaceholder
            cvcField.accessibilityLabel = cvcPlaceholder
        } else {
            cvcField.placeholder = defaultCVCPlaceholder()
            cvcField.accessibilityLabel = defaultCVCPlaceholder()
        }
    }

    func onChange() {
        if delegate?.responds(
            to: #selector(STPAddCardViewController.paymentCardTextFieldDidChange(_:)))
            ?? false
        {
            delegate?.paymentCardTextFieldDidChange?(self)
        }
        sendActions(for: .valueChanged)
    }

    // MARK: UIKeyInput
    /// :nodoc:
    @objc open var hasText: Bool {
        return numberField.hasText || expirationField.hasText || cvcField.hasText
    }

    /// :nodoc:
    @objc
    open func insertText(_ text: String) {
        currentFirstResponderField()?.insertText(text)
    }

    /// :nodoc:
    @objc
    open func deleteBackward() {
        currentFirstResponderField()?.deleteBackward()
    }

    /// :nodoc:
    @objc
    open class func keyPathsForValuesAffectingIsValid() -> Set<String> {
        return Set<String>([
            "viewModel.isValid",
            "viewModel.hasCompleteMetadataForCardNumber",
        ])
    }
}

/// This protocol allows a delegate to be notified when a payment text field's
/// contents change, which can in turn be used to take further actions depending
/// on the validity of its contents.
@objc public protocol STPPaymentCardTextFieldDelegate: NSObjectProtocol {
    /// Called when either the card number, expiration, or CVC changes. At this point,
    /// one can call `isValid` on the text field to determine, for example,
    /// whether or not to enable a button to submit the form. Example:
    /// - (void)paymentCardTextFieldDidChange:(STPPaymentCardTextField *)textField {
    /// self.paymentButton.enabled = textField.isValid;
    /// }
    /// - Parameter textField: the text field that has changed
    @objc optional func paymentCardTextFieldDidChange(_ textField: STPPaymentCardTextField)
    /// Called when editing begins in the text field as a whole.
    /// After receiving this callback, you will always also receive a callback for which
    /// specific subfield of the view began editing.
    @objc optional func paymentCardTextFieldDidBeginEditing(_ textField: STPPaymentCardTextField)
    /// Notification that the user pressed the `return` key after completely filling
    /// out the STPPaymentCardTextField with data that passes validation.
    /// The Stripe SDK is going to `resignFirstResponder` on the `STPPaymentCardTextField`
    /// to dismiss the keyboard after this delegate method returns, however if your app wants
    /// to do something more (ex: move first responder to another field), this is a good
    /// opportunity to do that.
    /// This is delivered *before* the corresponding `paymentCardTextFieldDidEndEditing:`
    /// - Parameter textField: The STPPaymentCardTextField that was being edited when the user pressed return
    @objc optional func paymentCardTextFieldWillEndEditing(
        forReturn textField: STPPaymentCardTextField)
    /// Called when editing ends in the text field as a whole.
    /// This callback is always preceded by an callback for which
    /// specific subfield of the view ended its editing.
    @objc optional func paymentCardTextFieldDidEndEditing(_ textField: STPPaymentCardTextField)
    /// Called when editing begins in the payment card field's number field.
    @objc optional func paymentCardTextFieldDidBeginEditingNumber(
        _ textField: STPPaymentCardTextField)
    /// Called when editing ends in the payment card field's number field.
    @objc optional func paymentCardTextFieldDidEndEditingNumber(
        _ textField: STPPaymentCardTextField)
    /// Called when editing begins in the payment card field's CVC field.
    @objc optional func paymentCardTextFieldDidBeginEditingCVC(_ textField: STPPaymentCardTextField)
    /// Called when editing ends in the payment card field's CVC field.
    @objc optional func paymentCardTextFieldDidEndEditingCVC(_ textField: STPPaymentCardTextField)
    /// Called when editing begins in the payment card field's expiration field.
    @objc optional func paymentCardTextFieldDidBeginEditingExpiration(
        _ textField: STPPaymentCardTextField)
    /// Called when editing ends in the payment card field's expiration field.
    @objc optional func paymentCardTextFieldDidEndEditingExpiration(
        _ textField: STPPaymentCardTextField)
    /// Called when editing begins in the payment card field's ZIP/postal code field.
    @objc optional func paymentCardTextFieldDidBeginEditingPostalCode(
        _ textField: STPPaymentCardTextField)
    /// Called when editing ends in the payment card field's ZIP/postal code field.
    @objc optional func paymentCardTextFieldDidEndEditingPostalCode(
        _ textField: STPPaymentCardTextField)
}

private let kCardLoadingAnimationDelay: TimeInterval = 0.1

/// :nodoc:
@_spi(STP) extension STPPaymentCardTextField: STPAnalyticsProtocol {
    @_spi(STP) public static var stp_analyticsIdentifier = "STPPaymentCardTextField"
}
